Fix talent outcome validation feedback
This commit is contained in:
@@ -749,6 +749,11 @@ error, or danger indicators.
|
||||
explicit “None” option when the field is optional.
|
||||
- Primary save actions sit at the lower right. Cancel is secondary.
|
||||
- Destructive/archive actions are visually separated from save actions.
|
||||
- Server validation returns errors for specific editor areas. Show a focused
|
||||
error summary with links to each affected area, mark invalid native controls
|
||||
with `aria-invalid`, and place the error text beside the relevant field or
|
||||
section. Preserve all entered values. Color supports the text and border
|
||||
treatment; it never carries the error state alone.
|
||||
|
||||
## Interaction rules
|
||||
|
||||
|
||||
+13
@@ -2380,3 +2380,16 @@
|
||||
grid-column:auto
|
||||
}
|
||||
}
|
||||
/* Linked field and section validation for dense ISAC editors. */
|
||||
.validation-summary{display:grid;gap:7px;margin:10px 0;padding:10px 12px;border:1px solid color-mix(in srgb,var(--shd-danger) 55%,var(--shd-border));border-left:2px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 8%,var(--shd-surface));scroll-margin-top:calc(var(--shd-header-height) + 12px)}
|
||||
.validation-summary:focus-visible{outline:1px solid var(--shd-danger);outline-offset:2px}
|
||||
.validation-summary>strong{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:1px}
|
||||
.validation-summary ul{display:grid;gap:4px;margin:0;padding:0;list-style:none}
|
||||
.validation-summary a{display:grid;grid-template-columns:minmax(120px,.32fr) minmax(0,1fr);gap:8px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.4}
|
||||
.validation-summary a:hover,.validation-summary a:focus-visible{color:var(--shd-text)}
|
||||
.validation-summary a span{color:var(--shd-danger);font-weight:900;text-transform:uppercase;letter-spacing:.6px}
|
||||
.validation-area-error{scroll-margin-top:calc(var(--shd-header-height) + 12px)}
|
||||
.validation-area-error.field,.validation-area-error>.field,.validation-area-error .icon-upload-field,.validation-area-error .mechanic-editor{border-color:var(--shd-danger)!important}
|
||||
.field-error{display:block;margin:5px 0 0;color:var(--shd-danger);font-size:var(--shd-type-telemetry);line-height:1.4}
|
||||
.mechanic-field-error{padding-left:10px;border-left:2px solid var(--shd-danger)}
|
||||
@media(max-width:560px){.validation-summary a{grid-template-columns:1fr;gap:1px}}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ConditionalMechanicDraftV1 } from './mechanic-editor';
|
||||
import { missingTalentDescriptionSlots, numberedTalentOutcomes } from './talent-description';
|
||||
|
||||
const draft = (outcomes: ConditionalMechanicDraftV1['clauses'][number]['outcomes']): ConditionalMechanicDraftV1 => ({
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [{
|
||||
key: 'primary',
|
||||
label: 'Primary',
|
||||
event: 'enemy_killed',
|
||||
if: { kind: 'true' },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes
|
||||
}]
|
||||
});
|
||||
|
||||
describe('talent description outcomes', () => {
|
||||
it('accepts a numbered restoration outcome as a description value', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
key: 'restore-health',
|
||||
label: 'Restore Health',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3,
|
||||
basis: 'max_health_percent',
|
||||
descriptionSlot: 1,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 }
|
||||
}
|
||||
}]));
|
||||
|
||||
expect(outcomes).toHaveLength(1);
|
||||
expect(outcomes[0]).toMatchObject({ kind: 'restoration', descriptionSlot: 1 });
|
||||
expect(missingTalentDescriptionSlots('Restore {1} over time.', outcomes)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports exact missing slots instead of relying on outcome count', () => {
|
||||
const outcomes = numberedTalentOutcomes(draft([{
|
||||
key: 'damage',
|
||||
label: 'Damage',
|
||||
phase: 'on_event',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
descriptionSlot: 2,
|
||||
variants: {
|
||||
I: { value: 10 },
|
||||
II: { value: 14 }
|
||||
}
|
||||
}]));
|
||||
|
||||
expect(missingTalentDescriptionSlots('{1} then {2} and {3}.', outcomes)).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
ConditionalMechanicDraftV1,
|
||||
ConditionalMechanicOutcomeDraft
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
|
||||
const VALUE_BEARING_OUTCOME_KINDS = new Set([
|
||||
'attribute_modifier',
|
||||
'direct_damage',
|
||||
'restoration',
|
||||
'resource_cost'
|
||||
]);
|
||||
|
||||
export const numberedTalentOutcomes = (
|
||||
draft: ConditionalMechanicDraftV1
|
||||
): ConditionalMechanicOutcomeDraft[] => {
|
||||
const outcomes = draft.clauses
|
||||
.flatMap((clause) => clause.outcomes)
|
||||
.filter((outcome) =>
|
||||
VALUE_BEARING_OUTCOME_KINDS.has(outcome.kind)
|
||||
&& Number.isInteger(outcome.descriptionSlot)
|
||||
&& Number(outcome.descriptionSlot) > 0
|
||||
)
|
||||
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
|
||||
|
||||
return [...new Map(outcomes.map((outcome) => [outcome.descriptionSlot, outcome])).values()];
|
||||
};
|
||||
|
||||
export const missingTalentDescriptionSlots = (
|
||||
template: string,
|
||||
outcomes: ConditionalMechanicOutcomeDraft[]
|
||||
) => {
|
||||
const availableSlots = new Set(outcomes.map((outcome) => Number(outcome.descriptionSlot)));
|
||||
return [...new Set(
|
||||
[...template.matchAll(/\{(\d+)\}/g)]
|
||||
.map((match) => Number(match[1]))
|
||||
.filter((slot) => !availableSlots.has(slot))
|
||||
)];
|
||||
};
|
||||
@@ -15,15 +15,14 @@ import {
|
||||
saveConditionalRuleInTransaction
|
||||
} from '$lib/server/conditional-effects';
|
||||
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
|
||||
import {
|
||||
missingTalentDescriptionSlots,
|
||||
numberedTalentOutcomes
|
||||
} from '$lib/calculations/talent-description';
|
||||
|
||||
type SharedEffect = { statId: string; valueI: number; valueII: number };
|
||||
|
||||
const descriptionOutcomes = (draft: ReturnType<typeof parseConditionalRulePayload>) => {
|
||||
const outcomes = draft.clauses.flatMap((clause) => clause.outcomes)
|
||||
.filter((outcome) => outcome.descriptionSlot)
|
||||
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
|
||||
return [...new Map(outcomes.map((outcome) => [outcome.descriptionSlot, outcome])).values()];
|
||||
};
|
||||
type SharedEffect = { slot: number; statId?: string; valueI: number; valueII: number };
|
||||
type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
|
||||
type TalentValidationError = { area: TalentErrorArea; message: string };
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
@@ -136,16 +135,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const parseEffects = (raw: FormDataEntryValue | null): SharedEffect[] => {
|
||||
const value = JSON.parse(String(raw ?? '[]')) as Array<{ statId: unknown; valueI: unknown; valueII: unknown }>;
|
||||
if (!Array.isArray(value)) throw new Error();
|
||||
return value.map((effect) => ({
|
||||
statId: String(effect.statId),
|
||||
valueI: Number(effect.valueI),
|
||||
valueII: Number(effect.valueII)
|
||||
}));
|
||||
};
|
||||
|
||||
const displayValue = (value: number, unit: unknown) => {
|
||||
const normalizedUnit = unit === 'flat' ? '' : String(unit ?? '');
|
||||
return normalizedUnit === 'Shots' ? `${value} ${normalizedUnit}` : `${value}${normalizedUnit}`;
|
||||
@@ -169,18 +158,34 @@ const renderDescription = (
|
||||
) => template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => {
|
||||
if (expression === 'duration') return durationSeconds === null ? placeholder : renderMechanicValue(durationSeconds, 'second');
|
||||
if (expression === 'max_stacks') return maxStacks === null ? placeholder : renderMechanicValue(maxStacks, 'stack');
|
||||
const effect = effects[Number(expression) - 1];
|
||||
return effect ? displayValue(tier === 'I' ? effect.valueI : effect.valueII, units.get(effect.statId)) : placeholder;
|
||||
const effect = effects.find((item) => item.slot === Number(expression));
|
||||
return effect
|
||||
? displayValue(
|
||||
tier === 'I' ? effect.valueI : effect.valueII,
|
||||
effect.statId ? units.get(effect.statId) : undefined
|
||||
)
|
||||
: placeholder;
|
||||
});
|
||||
|
||||
export const actions: Actions = {
|
||||
saveTalent: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const talentMode = id ? 'edit' : 'add';
|
||||
const reject = (status: number, errors: TalentValidationError[]) => fail(status, {
|
||||
talentError: errors[0]?.message ?? 'The talent could not be saved.',
|
||||
talentErrors: errors,
|
||||
talentMode,
|
||||
resultToken: randomUUID()
|
||||
});
|
||||
let iconMutation;
|
||||
try { iconMutation = await readIconMutation(form); }
|
||||
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { talentError: error.message }); throw error; }
|
||||
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
|
||||
catch (error) {
|
||||
if (error instanceof GameDataIconValidationError) return reject(400, [{ area: 'icon', message: error.message }]);
|
||||
throw error;
|
||||
}
|
||||
const name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
|
||||
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
|
||||
const db = await getDatabase();
|
||||
const conditionalRuleRaw = form.get('conditionalRule');
|
||||
@@ -195,36 +200,64 @@ export const actions: Actions = {
|
||||
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(400, { talentError: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' });
|
||||
return reject(400, [{
|
||||
area: 'mechanic',
|
||||
message: error instanceof Error ? error.message : 'The conditional mechanic is invalid.'
|
||||
}]);
|
||||
}
|
||||
const cooldownSeconds = conditionalRule.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null;
|
||||
const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null;
|
||||
const maxStacks = conditionalRule.pools.length
|
||||
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
|
||||
: null;
|
||||
const sharedEffects: SharedEffect[] = descriptionOutcomes(conditionalRule)
|
||||
.filter((effect) => effect.kind === 'attribute_modifier' && effect.attributeId)
|
||||
const descriptionOutcomes = numberedTalentOutcomes(conditionalRule);
|
||||
const sharedEffects: SharedEffect[] = descriptionOutcomes
|
||||
.map((effect) => ({
|
||||
statId: effect.attributeId ?? '',
|
||||
slot: Number(effect.descriptionSlot),
|
||||
...(effect.kind === 'attribute_modifier' && effect.attributeId ? { statId: effect.attributeId } : {}),
|
||||
valueI: effect.variants.I.value,
|
||||
valueII: effect.variants.II.value
|
||||
}));
|
||||
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' });
|
||||
if (!descriptionTemplate || !conditionalRule.clauses.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, mechanic branch, and at least one numbered Attribute outcome are required.' });
|
||||
if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every outcome needs valid Tier I and Tier II values.' });
|
||||
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
|
||||
if (placeholders.some((index) => index < 1 || index > sharedEffects.length)) return fail(400, { talentError: 'Description placeholders must reference an existing effect.' });
|
||||
if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { talentError: 'The description uses {duration}, but no duration is defined.' });
|
||||
if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) return fail(400, { talentError: 'The description uses {max_stacks}, but no maximum stack count is defined.' });
|
||||
const validationErrors: TalentValidationError[] = [];
|
||||
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) {
|
||||
validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' });
|
||||
}
|
||||
if (!descriptionTemplate) validationErrors.push({ area: 'description', message: 'Enter a shared description template.' });
|
||||
if (!conditionalRule.clauses.length) validationErrors.push({ area: 'mechanic', message: 'Add at least one mechanic branch.' });
|
||||
if (!sharedEffects.length) validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome.' });
|
||||
if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) {
|
||||
validationErrors.push({ area: 'outcomes', message: 'Every numbered outcome needs valid Tier I and Tier II values.' });
|
||||
}
|
||||
const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes);
|
||||
if (missingSlots.length) {
|
||||
validationErrors.push({
|
||||
area: 'description',
|
||||
message: `Description placeholder${missingSlots.length === 1 ? '' : 's'} ${missingSlots.map((slot) => `{${slot}}`).join(', ')} must reference numbered outcomes.`
|
||||
});
|
||||
validationErrors.push({ area: 'outcomes', message: 'Add or renumber outcomes to match every numbered description placeholder.' });
|
||||
}
|
||||
if (descriptionTemplate.includes('{duration}') && durationSeconds === null) {
|
||||
validationErrors.push({ area: 'description', message: 'The description uses {duration}, but no duration is defined.' });
|
||||
validationErrors.push({ area: 'mechanic', message: 'Define a duration for the {duration} description placeholder.' });
|
||||
}
|
||||
if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) {
|
||||
validationErrors.push({ area: 'description', message: 'The description uses {max_stacks}, but no maximum stack count is defined.' });
|
||||
validationErrors.push({ area: 'mechanic', message: 'Define a pool maximum for the {max_stacks} description placeholder.' });
|
||||
}
|
||||
if (validationErrors.length) return reject(400, validationErrors);
|
||||
const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category;
|
||||
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) return fail(409, { talentError: 'That talent already exists in this category.' });
|
||||
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) {
|
||||
return reject(409, [{ area: 'identity', message: 'That talent already exists in this category.' }]);
|
||||
}
|
||||
const statIds = conditionalRule.clauses.flatMap((clause) => clause.outcomes)
|
||||
.map((effect) => effect.attributeId)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
const statRows = statIds.length
|
||||
? await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).all(...statIds)
|
||||
: [];
|
||||
if (statRows.length !== new Set(statIds).size) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
|
||||
if (statRows.length !== new Set(statIds).size) {
|
||||
return reject(400, [{ area: 'outcomes', message: 'One or more selected attributes are unavailable.' }]);
|
||||
}
|
||||
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
|
||||
const talentId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
|
||||
const before = id ? {
|
||||
@@ -258,7 +291,7 @@ export const actions: Actions = {
|
||||
const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier);
|
||||
const tierId = String(tierRow?.id);
|
||||
await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
|
||||
for (const outcome of descriptionOutcomes(conditionalRule)) {
|
||||
for (const outcome of descriptionOutcomes) {
|
||||
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue;
|
||||
await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(
|
||||
randomUUID(),
|
||||
|
||||
@@ -9,11 +9,34 @@
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||
import { tick } from 'svelte';
|
||||
let { data, form } = $props();
|
||||
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
|
||||
let talent = $state<any>(null);
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state(0);
|
||||
let validationSummary = $state<HTMLElement | null>(null);
|
||||
type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
|
||||
type TalentValidationError = { area: TalentErrorArea; message: string };
|
||||
const actionForm = $derived(form as Record<string, any> | null | undefined);
|
||||
const talentErrors = $derived(
|
||||
Array.isArray(actionForm?.talentErrors)
|
||||
? actionForm.talentErrors as TalentValidationError[]
|
||||
: actionForm?.talentError
|
||||
? [{ area: 'mechanic', message: String(actionForm.talentError) }] as TalentValidationError[]
|
||||
: []
|
||||
);
|
||||
const errorsFor = (area: TalentErrorArea) => talentErrors.filter((error) => error.area === area);
|
||||
const hasErrors = (areas: TalentErrorArea[]) => areas.some((area) => errorsFor(area).length > 0);
|
||||
const areaLabel = (area: TalentErrorArea) => ({
|
||||
icon: 'Talent icon',
|
||||
identity: 'Talent identity',
|
||||
description: 'Description template',
|
||||
mechanic: 'Mechanic program',
|
||||
outcomes: 'Mechanic outcomes'
|
||||
}[area]);
|
||||
const areaTarget = (area: TalentErrorArea) =>
|
||||
area === 'outcomes' ? 'talent-mechanic' : `talent-${area}`;
|
||||
const blankRule = (): ConditionalMechanicDraftV1 => ({
|
||||
version: 1,
|
||||
simulationReadiness: 'captured_only',
|
||||
@@ -81,6 +104,8 @@
|
||||
if (form.talentSaved) {
|
||||
if (form.talentMode === 'add') iconResetToken += 1;
|
||||
talent = form.talentMode === 'add' ? blankTalent() : null;
|
||||
} else if (talentErrors.length) {
|
||||
void tick().then(() => validationSummary?.focus());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -95,9 +120,30 @@
|
||||
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
|
||||
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
|
||||
<div class="talent-icon-identity"><IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/><div class="talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} required/></div></div>
|
||||
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small></label>
|
||||
{#if talentErrors.length}
|
||||
<div class="validation-summary" role="alert" tabindex="-1" bind:this={validationSummary}>
|
||||
<strong>CHECK {new Set(talentErrors.map((error) => error.area)).size} HIGHLIGHTED {new Set(talentErrors.map((error) => error.area)).size === 1 ? 'AREA' : 'AREAS'}</strong>
|
||||
<ul>
|
||||
{#each talentErrors as error}
|
||||
<li><a href={`#${areaTarget(error.area)}`}><span>{areaLabel(error.area)}</span>{error.message}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="talent-icon-identity">
|
||||
<div id="talent-icon" class:validation-area-error={hasErrors(['icon'])}>
|
||||
<IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/>
|
||||
{#each errorsFor('icon') as error}<small class="field-error">{error.message}</small>{/each}
|
||||
</div>
|
||||
<div id="talent-identity" class="talent-identity" class:validation-area-error={hasErrors(['identity'])}>
|
||||
<label class="field">NAME<input name="name" bind:value={talent.name} required aria-invalid={hasErrors(['identity']) ? 'true' : undefined}/></label>
|
||||
<SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} required/>
|
||||
{#each errorsFor('identity') as error}<small class="field-error">{error.message}</small>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<label id="talent-description" class="field template-field" class:validation-area-error={hasErrors(['description'])}>SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required aria-invalid={hasErrors(['description']) ? 'true' : undefined}></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small>{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}</label>
|
||||
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
|
||||
<div id="talent-mechanic" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])}>
|
||||
<MechanicProgramEditor
|
||||
attributes={data.stats}
|
||||
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
|
||||
@@ -109,7 +155,8 @@
|
||||
}}
|
||||
bind:rule={talent.conditionalRule}
|
||||
/>
|
||||
{#if form?.talentError}<p class="error">{form.talentError}</p>{/if}
|
||||
{#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}<small class="field-error mechanic-field-error">{error.message}</small>{/each}
|
||||
</div>
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => talent = null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user