Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/components/Form/Form.stories.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {ref} from 'vue';
import Form from './Form.vue';
import FormErrorSummary from './FormErrorSummary.vue';
import {useContainerStateManager} from '@/composables/useContainerStateManager';
import FormBase from './mocks/form-base';
import FormMultilingual from './mocks/form-multilingual';
import FormGroups from './mocks/form-groups';
import FormUser from './mocks/form-user';
import FormDisplay from './mocks/form-display';
import FormConditionalDisplay from './mocks/form-conditional-display';
import {useForm} from '@/composables/useForm';
import {useLocalize} from '@/composables/useLocalize';
Expand Down Expand Up @@ -206,3 +208,56 @@ export const ClientSideConfigured = {

args: {},
};

export const DisplayOnly = {
render: (args) => ({
components: {PkpForm: Form},
setup() {
const {connectWithPayload, set} = useForm(args.form);
const payload = ref({
givenName: 'Jarda',
familyName: 'Kotesovec',
affiliation: 'PKP',
country: 'AI',
});
connectWithPayload(payload);
return {args, set};
},
template: `
<PkpForm v-bind="args.form" :display-only="true" @set="set" />
`,
}),

args: FormDisplay,
};

export const MultilingualDisplayOnly = {
render: (args) => ({
components: {PkpForm: Form},
setup() {
const {connectWithPayload} = useForm(args.form);
const payload = ref({
givenName: {en: 'Jarda', fr_CA: 'Jardous'},
familyName: {en: 'Kotesovec', fr_CA: ''},
affiliation: {en: 'PKP', fr_CA: 'PKP fr'},
country: 'AI',
});
connectWithPayload(payload);

const form = {
...args.form,
fields: args.form.fields.map((field) => ({
...field,
isMultilingual: true,
})),
};

return {form};
},
template: `
<PkpForm v-bind="form" :display-only="true" @set="set" />
`,
}),

args: FormDisplay,
};
11 changes: 10 additions & 1 deletion src/components/Form/Form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
:value="value"
/>
<FormLocales
v-if="availableLocales.length > 1"
v-if="availableLocales.length > 1 && !displayOnly"
:primary-locale-key="primaryLocale"
:locales="availableLocales"
:visible="visibleLocales"
Expand Down Expand Up @@ -63,6 +63,9 @@
:is-saving="isSaving"
:show-error-footer="showErrorFooter"
:spacing-variant="spacingVariant"
:display-only="displayOnly"
:locale-heading-element="localeHeadingElement"
:field-heading-element="fieldHeadingElement"
@change="fieldChanged"
@page-submitted="nextPage"
@previous-page="setCurrentPage(false)"
Expand Down Expand Up @@ -146,6 +149,12 @@ export default {
},
/** Defines wether to add default spacing ("default") or not("fullWidth"). This is useful when displaying the form in a Dialog, as the modal styling is already handled there. */
spacingVariant: String,
/** Whether the form fields are read-only */
displayOnly: {default: false, type: Boolean},
/** The heading element to use for locale labels when the form is in display mode */
localeHeadingElement: {required: false, default: 'h3', type: String},
/** The heading element to use for field labels when the form is in display mode */
fieldHeadingElement: {required: false, default: 'h4', type: String},
},
emits: [
/** When the form props need to be updated. The payload is an object with any keys that need to be modified. */
Expand Down
72 changes: 71 additions & 1 deletion src/components/Form/FormGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,34 @@
/>
</div>
<div class="pkpFormGroup__fields">
<template v-for="field in fieldsInGroup">
<div
v-if="displayOnly && hasMultilingualFields"
class="flex flex-col gap-y-6"
>
<div v-for="locale in availableLocales" :key="locale.key">
<component
:is="localeHeadingElement"
class="mb-1 text-2xl-bold text-heading"
>
{{ locale.label }}
</component>
<div class="flex flex-col gap-y-6">
<template v-for="field in fieldsInGroup" :key="field.name">
<div v-if="!field.hideOnDisplay && field.inputType !== 'hidden'">
<component
:is="
FormDisplayComponents[field.component] || FormDisplayDefault
"
:field="field"
:heading-element="fieldHeadingElement"
:display-locale="field.isMultilingual ? locale.key : ''"
></component>
</div>
</template>
</div>
</div>
</div>
<template v-for="field in fieldsInGroup" v-else :key="field.name">
<template v-if="field.isMultilingual">
<div :key="field.name" class="pkpFormGroup__localeGroup -pkpClearfix">
<div
Expand Down Expand Up @@ -50,8 +77,25 @@
</div>
</template>
<template v-else>
<template v-if="displayOnly">
<div
v-if="!field.hideOnDisplay"
:key="field.name"
class="mt-6 first:mt-0"
>
<component
:is="
FormDisplayComponents[field.component] || FormDisplayDefault
"
:field="field"
:heading-element="fieldHeadingElement"
v-bind="field.componentProps"
></component>
</div>
</template>
<component
:is="field.component"
v-else
v-bind="field.componentProps || field"
:key="field.name"
:all-errors="errors"
Expand All @@ -78,6 +122,7 @@ import FieldCheckbox from './fields/FieldCheckbox.vue';
import FieldColor from './fields/FieldColor.vue';
import FieldControlledVocab from './fields/FieldControlledVocab.vue';
import FieldCreditRoles from './fields/FieldCreditRoles.vue';
import FieldDate from './fields/FieldDate.vue';
import FieldPubId from './fields/FieldPubId.vue';
import FieldHtml from './fields/FieldHtml.vue';
import FieldMetadataSetting from './fields/FieldMetadataSetting.vue';
Expand All @@ -98,9 +143,20 @@ import FieldUpload from './fields/FieldUpload.vue';
import FieldSlider from './fields/FieldSlider.vue';
import FieldUploadImage from './fields/FieldUploadImage.vue';

// Form Display components
import FieldTextDisplay from './display/FieldTextDisplay.vue';
import FieldSelectDisplay from './display/FieldSelectDisplay.vue';
import FieldOptionsDisplay from './display/FieldOptionsDisplay.vue';

import {shouldShowFieldWithinGroup} from './formHelpers';
import {useId} from 'vue';

const FormDisplayComponents = {
'field-text': FieldTextDisplay,
'field-select': FieldSelectDisplay,
'field-options': FieldOptionsDisplay,
};

export default {
name: 'FormGroup',
components: {
Expand All @@ -114,6 +170,7 @@ export default {
FieldColor,
FieldControlledVocab,
FieldCreditRoles,
FieldDate,
FieldPubId,
FieldHtml,
FieldMetadataSetting,
Expand All @@ -133,6 +190,9 @@ export default {
FieldSlider,
FieldUpload,
FieldUploadImage,

// default field display component
FieldTextDisplay,
},
props: {
id: String,
Expand All @@ -153,12 +213,18 @@ export default {
default: () => 'default',
validator: (val) => ['default', 'fullWidth'].includes(val),
},
displayOnly: Boolean,
localeHeadingElement: String,
fieldHeadingElement: String,
},
emits: [
/** Emitted when a field prop changes. Payload: `(fieldName, propName, newValue, [localeKey])`. The `localeKey` will be null for fields that are not multilingual. This event is fired every time the `value` changes, so you should [debounce](https://www.npmjs.com/package/debounce) event callbacks that contain resource-intensive code. */
'change',
'set-errors',
],
data() {
return {FormDisplayComponents, FormDisplayDefault: FieldTextDisplay};
},

computed: {
/**
Expand Down Expand Up @@ -193,6 +259,10 @@ export default {
groupId() {
return useId();
},

hasMultilingualFields() {
return !!this.fields.find((field) => field.isMultilingual);
},
},
methods: {
/**
Expand Down
6 changes: 6 additions & 0 deletions src/components/Form/FormPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
:available-locales="availableLocales"
:spacing-variant="spacingVariant"
:form-id="formId"
:display-only="displayOnly"
:locale-heading-element="localeHeadingElement"
:field-heading-element="fieldHeadingElement"
@change="fieldChanged"
@set-errors="setErrors"
/>
Expand Down Expand Up @@ -117,6 +120,9 @@ export default {
},
},
spacingVariant: String,
displayOnly: Boolean,
localeHeadingElement: String,
fieldHeadingElement: String,
},
emits: [
/** Emitted when a field prop changes. Payload: `(fieldName, propName, newValue, [localeKey])`. The `localeKey` will be null for fields that are not multilingual. This event is fired every time the `value` changes, so you should [debounce](https://www.npmjs.com/package/debounce) event callbacks that contain resource-intensive code. */
Expand Down
138 changes: 138 additions & 0 deletions src/components/Form/fields/FieldDate.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<template>
<div class="pkpFormField pkpFormField--date">
<div class="pkpFormField__heading">
<FormFieldLabel
:control-id="controlId"
:label="label"
:locale-label="localeLabel"
:is-required="isRequired"
:required-label="t('common.required')"
:multilingual-label="multilingualLabel"
class="align-middle"
/>
<Tooltip
v-if="isPrimaryLocale && tooltip"
aria-hidden="true"
:tooltip="tooltip"
label=""
/>
<span
v-if="isPrimaryLocale && tooltip"
:id="describedByTooltipId"
v-strip-unsafe-html="tooltip"
class="-screenReader"
/>
</div>

<div
v-if="isPrimaryLocale && description"
:id="describedByDescriptionId"
v-strip-unsafe-html="description"
class="pkpFormField__description semantic-defaults"
/>

<div class="pkpFormField__control">
<input
:id="controlId"
ref="input"
v-model="currentValue"
type="date"
:name="localizedName"
:aria-describedby="describedByIds"
:aria-invalid="!!errors?.length"
:disabled="disabled"
:required="isRequired"
:min="computedMin"
:max="computedMax"
class="pkpFormField__input"
/>
<FieldError
v-if="errors && errors.length"
:id="describedByErrorId"
:messages="errors"
/>
</div>
</div>
</template>

<script>
import FieldBase from './FieldBase.vue';
import Tooltip from '@/components/Tooltip/Tooltip.vue';

export default {
name: 'FieldDate',
components: {
Tooltip,
},
extends: FieldBase,
props: {
/** Minimum date, can be 'today', 'tomorrow', 'yesterday', or YYYY-MM-DD */
min: {
type: String,
default: null,
},
/** Maximum date, can be 'today', 'tomorrow', 'yesterday', or YYYY-MM-DD */
max: {
type: String,
default: null,
},
/** If the field is disabled */
disabled: {
type: Boolean,
default: false,
},
},
computed: {
computedMin() {
const minDate = this.resolveDate(this.min);
const maxDate = this.resolveDate(this.max);
if (minDate && maxDate && minDate > maxDate) {
console.warn(
`[FieldDate] min (${minDate}) is after max (${maxDate}). Ignoring min.`,
);
return null;
}
return minDate;
},
computedMax() {
const minDate = this.resolveDate(this.min);
const maxDate = this.resolveDate(this.max);
if (minDate && maxDate && minDate > maxDate) {
console.warn(
`[FieldDate] max (${maxDate}) is before min (${minDate}). Ignoring max.`,
);
return null;
}
return maxDate;
},
},
methods: {
/** Resolve relative date keywords to YYYY-MM-DD */
resolveDate(value) {
if (!value) return undefined;
const today = new Date();

if (value instanceof Date) return value.toISOString().split('T')[0];

switch (value.toLowerCase()) {
case 'today':
return today.toISOString().split('T')[0];
case 'tomorrow': {
const t = new Date();
t.setDate(today.getDate() + 1);
return t.toISOString().split('T')[0];
}
case 'yesterday': {
const t = new Date();
t.setDate(today.getDate() - 1);
return t.toISOString().split('T')[0];
}
default:
// Accept literal YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
return undefined;
}
},
},
};
</script>
Loading